iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 7

Day 7|D (依賴反轉原則) 柴咖啡如何優雅解開資料庫與 LINE 的枷鎖?

  • 分享至 

  • xImage
  •  

上一篇用 ISP 讓結帳跟集點兩邊,各自只依賴自己用得到的方法

這篇我們來看 SOLID 最後一條:Dependency Inversion Principle(依賴反轉原則,簡稱 DIP)

DIP 是什麼

一樣是 Uncle Bob 提出的,最早出現在 1996 年他寫給 C++ Report 的一篇文章,後來收進《Agile Software Development: Principles, Patterns, and Practices》這本書裡

原文是這樣說的:

High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.

在查找資料的時候,伊果大大將 DIP 的定義詮釋得很好:

高層模組不應該依賴低層模組,兩者都應該依賴抽象

看到「高層」「低層」這兩個詞,我理解成:
高層模組是「做決策、訂規則」的那一層(例如「怎麼結帳」)
低層模組是「做細節、幹實事」的那一層(例如「怎麼存進資料庫」、「怎麼發一則 LINE 通知」)

直覺會覺得,做決策的那層去呼叫做細節的那層,天經地義,這不就是由上而下一層層呼叫下去嗎?

但 DIP 所說的是:這個依賴方向反了

反了? 怎麼說呢?

不該是高層直接認得低層的具體長相,而是兩邊都應該只認得一份共同的「抽象」

是不是越看越抽象? 越看越想睡?

老樣子,我們用一個小例子感受一下

最近我的房間裝了新燈,還不錯,那我要設置個機關去開關它

public class Bulb  // 低層模組:一顆燈泡
{
    public void TurnOn() => Console.WriteLine("燈泡亮了");
    public void TurnOff() => Console.WriteLine("燈泡暗了");
}

public class Switch  // 高層模組:負責"決定要不要通電"這件事
{
    private readonly Bulb _bulb = new Bulb(); // 開關 直接「依賴」具體的 Bulb
    public void Toggle(bool on)
    {
        if (on) _bulb.TurnOn();
        else _bulb.TurnOff();
    }
}

目前看起來沒問題,但哪天我突然想幫房間換一顆「LED 燈條」,或乾脆換成「風扇」,比較涼,那 Switch 這個類別就得跟著改,因為它從頭到尾綁死的是 Bulb 這個具體型別

Switch 明明只是想「決定要不要通電」,卻被迫認識「燈泡」這個細節並且依賴它,那我換風扇呢
Switch 就得要依賴風扇,換了一百個燈飾,我就要換一百次依賴嗎? NO NO NO

那我們該怎麼辦呢? 我們來建立 interface

public interface ISwitchable  // 抽象,開關依賴這個,燈泡跟風扇都實作這個
{
    void TurnOn();
    void TurnOff();
}

public class Bulb : ISwitchable
{
    public void TurnOn() => Console.WriteLine("燈泡亮了");
    public void TurnOff() => Console.WriteLine("燈泡暗了");
}

public class Fan : ISwitchable  // 之後想接風扇,一樣照做就好,不用動 Switch
{
    public void TurnOn() => Console.WriteLine("風扇轉了");
    public void TurnOff() => Console.WriteLine("風扇停了");
}

public class Switch
{
    private readonly ISwitchable _device;

    public Switch(ISwitchable device) => _device = device; // 從外面帶進來,不自己 new

    public void Toggle(bool on)
    {
        if (on) _device.TurnOn();
        else _device.TurnOff();
    }
}

現在不管接的是燈泡還是風扇,Switch 都不用改了(可喜可賀),因為它只認得 ISwitchable 這個抽象,不需要認得任何一個裝置

之後想再接第三種裝置,一樣讓它實作 ISwitchable 就好

這時候你可能會想,interface 不是介面嗎,怎麼扯到抽象了,嫌我學的不夠多是不是?

沒有沒有,「抽象」講的是一個更廣的概念,不完全等於 interface 這個語法關鍵字

只要一個型別「只講定義要做什麼,不管實際怎麼做」,都算是一種抽象

在 C# 裡常見的做法有兩種:

  1. interface
  2. abstract class(抽象類別)

兩者都可以拿來當 DIP 說的那份「抽象」,只是 interface 更乾淨、不帶任何實作,所以後面都直接拿interface 來示範

那我們再往下走一點,順便釐清一個常搞混的地方:DIP 不等於 DI

依賴反轉原則(Dependency Inversion Principle, DIP)

跟依賴注入(Dependency Injection, DI),常常被講成同一件事,其實不是

  • DIP 是一個「設計原則」,談的是模組之間該依賴誰,應該依賴抽象,不要依賴具體實作
  • DI 是一個「寫法/技巧」,談的是一個物件怎麼「拿到」它依賴的東西,透過建構式或屬性,從外面「注入」進來,而不是自己在裡面 new

上面 Switch 的建構式改成吃 ISwitchable device,不再自己 new Bulb(),這個動作是屬於 DI

但讓 Switch 不用管背後是燈泡還是風扇的,是 ISwitchable 這個抽象本身,這才是 DIP 的設計原則

如果 Switch 的建構式吃的是具體的 Bulb bulb,一樣是從外面注入進來,一樣是 DI,卻依然沒有做到依賴反轉,因為 Switch 還是認得 Bulb 這個具體型別,如果換成 Fan 一樣要改建構式的型別

好我們再延伸這個例子,去感覺 DI 跟 DIP

public class Bulb  // 低層模組:一顆燈泡
{
    public void TurnOn() => Console.WriteLine("燈泡亮了");
    public void TurnOff() => Console.WriteLine("燈泡暗了");
}

public class Switch  // 高層模組
{
    private readonly Bulb _bulb;

    // 從外面「注入」進來,不是自己在裡面 new,這個動作是 DI 沒錯
    public Switch(Bulb bulb) => _bulb = bulb;

    public void Toggle(bool on)
    {
        if (on) _bulb.TurnOn();
        else _bulb.TurnOff();
    }
}

var bulb = new Bulb();
var s = new Switch(bulb); // 從外面帶進來,符合 DI 的寫法
s.Toggle(true);

看起來已經是「從外面帶進來」了,是不是就過關了?還沒

哪天想幫房間換一顆風扇:

public class Fan
{
    public void TurnOn() => Console.WriteLine("風扇轉了");
    public void TurnOff() => Console.WriteLine("風扇停了");
}

public class Switch
{
    private readonly Fan _fan; // 建構式的型別要跟著改

    public Switch(Fan fan) => _fan = fan; // 一樣是 DI,一樣從外面注入

    public void Toggle(bool on)
    {
        if (on) _fan.TurnOn();
        else _fan.TurnOff();
    }
}

問題就浮現了:Switch 的建構式從 Bulb bulb 改成 Fan fan,代表 Switch 這個類別本身也要跟著改

換言之:DI 是常拿來實現 DIP 的手段,但用了 DI 不代表會自動符合 DIP

好,我們再來看看柴咖啡花生了什麼事

小黑的新要求

阿柴用 SRP 將功能單一職責化,又陸續用 OCP、LSP、ISP 把促銷邏輯整理乾淨,結帳服務看起來一天比一天健康

覺得自己已經是萬中選一的奇才,直到某天...

小黑最近找了個顧問幫忙看系統,顧問第一句話就問:「你們結帳這段邏輯,有沒有單元測試?」

https://ithelp.ithome.com.tw/upload/images/20260904/20183470HGujH2vUQR.jpg

阿柴:「呃...沒有,但邏輯不複雜,應該還好」

顧問:「沒事先補起來吧,不然以後改壞了都不知道」

阿柴心想,好,都學到 SOLID 了,這應該不難,打開 CheckoutService,看了看自己寫的 code

public class CheckoutService
{
    private readonly OrderCalculator _calculator;
    private readonly OrderRepository _repository;
    private readonly LineNotifier _notifier;

    public CheckoutService(OrderCalculator calculator)
    {
        _calculator = calculator;
        _repository = new OrderRepository();
        _notifier = new LineNotifier();
    }

    public decimal Checkout(Order order, IDiscount discount)
    {
        decimal total = _calculator.Calculate(order);
        total = discount.Apply(order, total);

        _repository.Save(order);   // 寫進資料庫
        _notifier.Notify(total);   // 發出 LINE 通知

        return total;
    }
}

阿柴依顧問教的寫了一個單元測試(先不討論怎麼測試),餵一筆假訂單進去,跑起來

結果:

  • 測試資料庫多了一筆「測試訂單 999 元」
  • 老闆的手機收到一則「新訂單!金額:999」的 LINE 通知,一頭霧水回訊息問:「這誰點的?現在是半夜」

阿柴又要開始冒冷汗了

那問題出在哪呢

再回去看程式碼以前,我們從這兩個結果倒推回去

  1. 為什麼測試會真的寫進資料庫?

代表「存訂單」這件事,不會去管是誰呼叫的、為了什麼目的呼叫,跑的都是同一份「連上資料庫」的邏輯

沒有任何地方可以插手說「揪斗嘛爹! 這次只是測試,不要真的存!」

  1. 為什麼老闆的手機會收到通知?

同樣的道理,「通知老闆」這件事也沒辦法被攔下來,測試跟正式環境走的是同一條路,一路通到真的打出去的 LINE API

兩個症狀,指向同一個源頭:結帳服務內部,「怎麼存資料」跟「怎麼發通知」,都是自己生出來的具體做法,不是外面傳進來、可以替換掉的東西

兄弟,我們再回去看一下程式碼,發現了一件事

public CheckoutService(OrderCalculator calculator)
{
    _calculator = calculator;
    _repository = new OrderRepository(); // 建構式裡,高層模組直接 new 低層模組
    _notifier = new LineNotifier();       // 一樣直接 new
}

這裡高層是誰? 是CheckoutService 它的職責是「決定怎麼結帳」——算金額、套用促銷,這是高層的商業邏輯

但它建構式裡卻自己 new OrderRepository()new LineNotifier(),直接綁死了依賴「怎麼存進資料庫」「怎麼打 LINE API」這兩個低層細節

當呼叫 Checkout() 這個「結帳」動作,本來不該連帶真的去戳資料庫、真的打出網路請求,但因為 CheckoutService 認得的是這兩個具體類別,測試沒辦法在中間插一個假的版本進去,只能眼睜睜看著它們被真的執行

public decimal Checkout(Order order, IDiscount discount)
{
  decimal total = _calculator.Calculate(order);
  total = discount.Apply(order, total);

  _repository.Save(order);   // 寫進資料庫
  _notifier.Notify(total);   // 發出 LINE 通知

  return total;
}

這是 DIP 想擋下的狀況:高層模組(結帳的商業邏輯),依賴了低層模組(資料庫、LINE 通知)的具體實作,而不是依賴一份抽象

那我們該怎麼安全處理呢

先把「低層在做的事」抽成抽象,讓 CheckoutService 只認得抽象,不認得具體是資料庫還是 LINE

public interface IOrderRepository
{
    void Save(Order order);
}

public interface INotifier
{
    void Notify(decimal total);
}

public class OrderRepository : IOrderRepository  // 這裡是實際會存資料庫的類別
{
    public void Save(Order order)
    {
        var db = new AppDbContext();
        db.Orders.Add(order);
        db.SaveChanges();
    }
}

public class LineNotifier : INotifier           // 這裡是實際會通知 Line 的類別
{
    public void Notify(decimal total)
    {
        var client = new HttpClient();
        client.PostAsync("https://notify-api.line.me/...", new StringContent($"新訂單!金額:{total}"));
    }
}

CheckoutService 改成從建構式吃這兩個抽象,不再自己 new

public class CheckoutService
{
    private readonly OrderCalculator _calculator;
    private readonly IOrderRepository _repository;
    private readonly INotifier _notifier;

    public CheckoutService(OrderCalculator calculator, IOrderRepository repository, INotifier notifier)
    {
        _calculator = calculator;
        _repository = repository;
        _notifier = notifier;
    }

    public decimal Checkout(Order order, IDiscount discount)
    {
        decimal total = _calculator.Calculate(order);
        total = discount.Apply(order, total);

        _repository.Save(order);
        _notifier.Notify(total);

        return total;
    }
}

看來這時候,我們完成所謂的 DIP 風格,讓高層模組跟低層模組,共同依賴一份抽象

正式上線的地方,帶真正的 OrderRepositoryLineNotifier 進去

疑? 那我們剛剛提到的測試呢? 該怎麼處理? 那就讓測試模組去實作這個介面

public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order); // 只存在記憶體,不碰真的資料庫
}

public class FakeNotifier : INotifier
{
    public decimal? NotifiedAmount { get; private set; }
    public void Notify(decimal total) => NotifiedAmount = total; // 只記錄下來,不真的發 LINE
}
var service = new CheckoutService(orderCalculator, new FakeOrderRepository(), new FakeNotifier());
var total = service.Checkout(testOrder, new PercentageDiscount(0.9m));

CheckoutService 從頭到尾沒被改過算錢的邏輯,只是換了個「怎麼存」「怎麼通知」的實作進來,小黑老闆也不會在半夜醒來收到訊息了

今天寫的比較多,我們來重新整理一下,應該有很多夥伴跟我一樣在第一次理解依賴反轉的時候會想

「 你剛剛舉的例子我都懂,但反轉是什麼具體的概念? 反在哪? 嗯? 回答我!」

我們想想,如果今天沒有 DIP 這個概念之前,正常傳遞的過程,可能會像是這樣(回到我們起初說的燈泡的概念)

Switch (高層) ──依賴──> Bulb (低層)

只有一條箭頭,高層伸手去依賴低層,這是我們直覺以為「理所當然」的方向,做決策的人,去呼叫做細節的人

但延伸出來的風險,就會如同上述所說,維護成本會提高

那加入 DIP 的設計理念呢

Switch (高層) ──依賴──> ISwitchable <──實作(依賴)── Bulb (低層)

反過來了,現在不管高層決策或是底層實作,都共同依賴(實作)了 ISwitchable

換句話說

以往會覺得:低層模組(比如燈泡)應該自己決定「我有哪些方法(開關)」,高層模組配合著去用,低層是「規格制定者」,高層是「配合的人」

但 DIP 反過來:ISwitchable 這個抽象裡的 TurnOn()、TurnOff(),是照著高層 Switch 的需求(我只需要「開」跟「關」)去定義的,不是照著 Bulb 內部想怎麼做去定義的

Bulb 反而要反過來配合這份「由高層需求定義出來」的規格去實作,這才是「反轉」的核心,誰有資格決定契約長什麼樣子,從低層反轉到了高層

用 DIP 當照妖鏡

下次看到依賴關係,可以反問自己:

  • 這段商業邏輯,能不能在不碰資料庫、不打網路請求的情況下被測試? 如果不行,通常就是高層直接綁死了低層的訊號
  • 如果要換一套實作(換資料庫廠商、換通知管道),需要改動這個 class 內部的邏輯嗎? 需要的話,代表它依賴的是具體實作,不是抽象

DIP 的邊界在哪

那是不是每個 class 都要包一層 interface,才叫做「有在遵守 DIP」? 還是我乾脆全包好不好? NO NO NO

判斷標準不是「有沒有介面」,而是:這個依賴,未來有沒有可能需要被替換、或者需要在測試時被隔離

OrderCalculator 這種純計算邏輯(可以在 Day4 找到程式碼),沒有外部依賴,也沒人會想突然換一套演算法進去測試(應該沒人?),就也沒必要為了「符合 DIP」硬包一層抽象

真正該包抽象的,是那些跨越系統邊界的東西,資料庫、外部 API、檔案、時間、亂數這類「細節容易變、容易需要在測試裡假裝一份」的低層模組

DIP 該保護的是「高層的商業邏輯,不因為低層細節怎麼變而被迫跟著改」,不是「every class 都要包一層 interface」


到這裡,辛苦了朋友,SOLID 五個原則總算走完一輪了,剛好遇到店休,阿柴也可以喘口氣,之後我們來回顧一下這五個原則湊在一起時,會不會互相打架,還有 KISS、YAGNI、DRY 這些原則,怎麼制衡「用力過猛的 SOLID」

延伸閱讀資料:

Clean Coder

依賴反向原則 (Dependency-Inversion Principle, DIP)
菜雞與物件導向 (14): 依賴反轉原則


上一篇
Day 6|I(介面隔離) 會員制上線,如何不讓集點功能搞垮結帳系統?
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言